Skip to content

feat(backup): automatic iCloud Drive backup with restore after app deletion - #15

Merged
markm39 merged 1 commit into
mainfrom
feat/icloud-backup
Aug 30, 2026
Merged

feat(backup): automatic iCloud Drive backup with restore after app deletion#15
markm39 merged 1 commit into
mainfrom
feat/icloud-backup

Conversation

@markm39

@markm39 markm39 commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Why

A customer permanently lost their notes by deleting the app — the durability work in #14 cannot survive that, because deletion wipes the Documents directory. Notes now mirror automatically into the user's own iCloud Drive container, which survives app deletion, so a reinstall offers a one-tap restore. No account, no server, no sync UI: disk stays the source of truth (local-first), the mirror follows it.

Design

  • Native module (ICloudBackupModule, Swift): ubiquity container resolution, evicted-file download, and generic file ops. Required because expo-file-system refuses paths outside the app sandbox scopes — which includes the iCloud container (a real bug the simulator E2E caught before it shipped).
  • Pure engine (backupEngine.ts, env-injected, node-tested): debounced (8s) mirror sync copying only changed files (size/mtime manifest diff, single-call native directory listing), and restore into an empty library only, with every backup-derived path validated (isSafeRelPath + restricted to the three data dirs).
  • Deletions propagate only via tombstones (deletedNoteIds in the catalog, pruned after 90 days). A file merely missing locally — fresh install, partial restore — never deletes its backup copy, and an empty local library never overwrites a backup that has notes. This design exists because E2E caught a catastrophic race: the startup sync on a fresh install wiped the backup while the restore prompt was still on screen. That scenario is now a regression test (DISASTER REPRO) and was re-verified live.
  • UI: default on; off-switch with a destructive-styled confirmation and an honest status line in the support sheet ("Automatic — your notes survive app deletion" / "iCloud unavailable — sign in…" / "Off — notes exist only on this device"). Hidden on Android until a backup exists there.

Verification

  • 22 new node tests (43 total) incl. tombstone semantics, empty-local guard, traversal rejection, corrupt manifest/catalog, evicted files, partial failures
  • Full simulator E2E on iPad via a dev-only container override that survives simctl uninstall: first mirror, change propagation, delete app → reinstall → restore prompt → 2 files restored → notes open, tombstoned delete removes only that note from backup, toggle off (confirm) / on (instant catch-up), corrupt manifest self-heals, iCloud-unavailable degrades visibly, and the backup-wipe race regression (30s in prompt state, backup intact)
  • Security audit: no Critical/High/Medium; the one Low (restrict restore writes to data dirs) is applied
  • Typecheck, Metro export, native build all clean

Before releasing this (not part of the PR)

  1. The iCloud container iCloud.com.builderpro.opennotes must exist on the App ID in the developer portal — the first archive with -allowProvisioningUpdates should register it, but verify before submitting.
  2. App Store privacy labels + privacy policy need a line about notes being stored in the user's own iCloud (Apple-encrypted, developer has no access).

https://claude.ai/code/session_01Q73aeGnnUsUZ7BLLXJq2u5

…letion

Notes now mirror to the user's own iCloud Drive container so deleting
the app no longer destroys them. No account, no server, no sync UI -
disk stays the source of truth and the mirror follows it, in keeping
with local-first.

- Native ICloudBackupModule (Swift): ubiquity container resolution,
  evicted-file download, and generic file ops - expo-file-system cannot
  reach paths outside the app sandbox, which includes the container.
- Pure backup engine (backupEngine.ts) with injected env, node-tested:
  debounced mirror sync (changed files only, one-call native directory
  listing), manifest with size/mtime diffing, and restore into an empty
  library with per-path safety validation restricted to the data dirs.
- Deletions propagate ONLY via catalog tombstones (deletedNoteIds,
  pruned after 90 days). A file merely missing locally - fresh install,
  partial restore, older device - never deletes its backup copy, and an
  empty local library never overwrites a backup that has notes. This
  guard exists because simulator E2E caught the reinstall race where a
  startup sync wiped the backup while the restore prompt was on screen.
- Restore-on-reinstall: when the library is empty and the backup has
  notes, the library offers a one-tap restore; catalog reconciliation
  recovers anything the mirrored catalog missed.
- Default on, with an off switch (confirmed, destructive-styled) and an
  honest status line in the support sheet; hidden on Android until a
  backup implementation exists there. iCloud signed-out degrades to a
  visible 'unavailable' state and self-heals when it appears.
- Dev-only container override enables full E2E: verified on iPad
  simulator - first mirror, change propagation, delete-app/reinstall/
  restore, tombstoned delete, toggle off/on with catch-up, corrupt
  manifest self-heal, unavailable, and the backup-wipe regression.

Claude-Session: https://claude.ai/code/session_01Q73aeGnnUsUZ7BLLXJq2u5
@markm39
markm39 merged commit 5b20809 into main Aug 30, 2026
1 check passed
@markm39
markm39 deleted the feat/icloud-backup branch August 30, 2026 06:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant